NextJS 다중 탭 환경에서 로그인/로그아웃 동기화 (멀티탭 세션 일관성)
2026-01-19
멀티 탭 로그인/로그아웃 동기화, 어디까지 책임져야 할까
Next.js 프로젝트를 진행하면서 정말 골치 아팠던 게 하나 있었습니다. 바로 멀티 탭 인증 동기화 문제였죠. 사용자들은 당연하게 여러 탭을 열어두고 쓰는데, 한 탭에서 로그인하면 다른 탭도 자동으로 로그인되길 기대합니다. 하지만 막상 구현하려니 생각보다 훨씬 복잡한 문제들이 튀어나왔습니다.
문제: 각 탭의 고립성
실제로 우리 서비스에서 겪었던 상황들을 보면
- 탭 A에서 로그인했는데 → 탭 B는 여전히 로그인 페이지 그대로
- 탭 B에서 로그아웃했는데 → 탭 A는 계속 로그인 상태 유지
- 탭 A에서 A 계정으로 로그인, 탭 B에서 B 계정 로그인 시도 → 새로고침하면 다시 A 계정으로 롤백되는 황당한 상황
- 쿠키는 이미 만료됐는데, UI는 여전히 "Taeseong님 환영합니다" 표시
핵심 원인은 명확했습니다. 인증의 진짜 기준은 서버의 httpOnly 쿠키인데, 클라이언트 상태(Zustand)는 각 탭마다 완전히 독립적으로 존재하고 있었던 거죠. 그러니 당연히 꼬일 수밖에 없었습니다.
해결 원칙: 누가 진짜 주인인가
문제를 풀기 위해 먼저 원칙을 정했습니다.
- 인증의 단일 진실(Source of Truth)은 서버의 httpOnly 쿠키
- 클라이언트 상태는 "캐시"일 뿐, 언제든 버릴 수 있어야 함
- 멀티 탭에서는 상태 변경을 반드시 전파해야 함
즉, 로그인/로그아웃의 판단은 서버가 하고, 클라이언트는 그 상태를 각 탭에 동기화하는 역할만 합니다.
BroadcastChannel을 선택한 이유
멀티 탭 동기화 방법은 여러 가지가 있습니다. 서버 폴링, 쿠키 변화 감지, Service Worker, localStorage 이벤트 등등. 최종적으로 선택한 건 BroadcastChannel + localStorage 이벤트 폴백 조합이었습니다.
// BroadcastChannel 사용 예시
const channel = new BroadcastChannel('auth-sync');
// 탭 A에서 로그인 성공 시
channel.postMessage({
type: 'LOGIN_SYNC',
user: { userId: 123, email: 'user@example.com' }
});
// 다른 탭들이 즉시 수신 (탭 A 제외)
channel.addEventListener('message', (event) => {
console.log('다른 탭에서 로그인:', event.data.user);
});선택 이유는 간단했습니다.
- 같은 origin의 탭 간 실시간 통신에 최적화되어 있음
- 서버 부하 없음 (클라이언트끼리만 통신)
- localStorage 이벤트를 폴백으로 두면 구형 브라우저도 커버 가능
- 구현 난이도 대비 안정성이 우수함
그리고 각 탭을 구분하기 위해 sessionStorage에 고유 ID를 저장했습니다.
const getTabId = () => {
let tabId = sessionStorage.getItem('auth-tab-id');
if (!tabId) {
tabId = crypto.randomUUID();
sessionStorage.setItem('auth-tab-id', tabId);
}
return tabId;
};이렇게 하면 메시지를 보낸 탭이 자신의 메시지를 다시 받는 걸 방지할 수 있습니다. 각 탭은 자기가 보낸 메시지의 sourceId를 체크해서 무시하는 방식이죠.
실제 구현: 로그인 흐름
1) 서버에서 쿠키 설정
로그인 요청은 서버 액션(login.action.ts)에서 처리합니다. Nest 서버와 Spring Boot 서버를 병렬로 호출하고, Nest 인증이 성공하면 다음 작업을 수행합니다.
// login.action.ts
export const loginAction = async (formData: FormData) => {
const loginResponses = await login({ email, password });
const nestSuccess = loginResponses.some(
r => r.code === SUCCESS_CODE && !!r.data
);
if (nestSuccess) {
// accessToken → httpOnly 쿠키
// refresh_token 재설정
redirect('/'); // 쿠키 설정 후 즉시 리다이렉트
}
};이때 설정되는 쿠키들은 모두 httpOnly로 설정됩니다. JavaScript로는 접근할 수 없어서 XSS 공격으로부터 안전하죠.
Set-Cookie: accessToken=eyJhbGci...; HttpOnly; Secure; SameSite=Lax
Set-Cookie: refresh_token=eyJyZWZy...; HttpOnly; Secure; SameSite=Lax2) 로그인 후 화면인 after-login 레이아웃에서 user 구성
서버 컴포넌트인 레이아웃에서 쿠키의 accessToken을 디코딩해 사용자 정보를 얻습니다.
// app/(after-login)/layout.tsx
export default async function AfterLoginLayout({ children }) {
const user = await getServerUserFromCookies(); // 쿠키 디코딩
return (
<>
<UserHydrator user={user} />
{children}
</>
);
}getServerUserFromCookies는 단순히 쿠키에서 accessToken을 읽어서 디코딩하는 함수입니다. 서버 측에서만 실행되기 때문에 httpOnly 쿠키에 접근할 수 있죠.
3) UserHydrator가 동기화의 핵심
UserHydrator는 서버-클라이언트 상태를 동기화하는 핵심 컴포넌트입니다. 서버에서 받은 사용자 정보를 Zustand에 저장하고, 다른 탭에 로그인 이벤트를 전파합니다.
// UserHydrator.tsx
'use client';
export function UserHydrator({ user }) {
const login = useUserStore(s => s.login);
const clearUser = useUserStore(s => s.clearUser);
const router = useRouter();
// 초기 hydration
useEffect(() => {
if (user) {
login(user); // Zustand에 저장
publishLoginSync(user); // 다른 탭에 알림
} else {
logout(); // 쿠키 없으면 로그아웃
}
}, [user]);
// 다른 탭의 이벤트 구독
useEffect(() => {
return subscribeAuthSync((message) => {
if (message.type === 'LOGIN_SYNC' && message.user) {
login(message.user);
router.refresh(); // 서버 상태 다시 가져오기
}
if (message.type === 'LOGOUT_SYNC') {
clearUser(); // 서버 호출 없이 클라이언트만 정리
router.replace('/login');
}
});
}, []);
return null; // UI 없음
}이 컴포넌트가 SSR/CSR 경계에서 작동하면서, 서버 기준 인증 상태를 클라이언트가 한 번 더 검증하는 구조가 됩니다.
로그아웃은 반대 방향으로
로그아웃은 로그인의 반대 방향으로 흐릅니다.
- UI에서 로그아웃 버튼 클릭
userStore.logout()실행/api/logout호출 → 서버 쿠키 전체 삭제- localStorage(
user-storage) 제거 - 상태 초기화
publishLogoutSync()실행
// userStore.ts
logout: async () => {
// 서버에 로그아웃 요청 (쿠키 삭제)
try {
await fetch('/api/logout', { method: 'POST' });
} catch {
// 서버 실패해도 클라이언트는 로그아웃 진행
}
// localStorage 삭제
localStorage.removeItem('user-storage');
// 상태 초기화
set({ user: null });
// 다른 탭에 알림
publishLogoutSync();
}다른 탭들은 UserHydrator에서 LOGOUT_SYNC를 받으면 즉시 clearUser()를 실행하고 /login으로 이동합니다.
if (message.type === 'LOGOUT_SYNC') {
clearUser(); // publishLogoutSync 호출 안 함 (무한 루프 방지)
router.replace('/login');
}여기서 중요한 건 clearUser()는 publishLogoutSync()를 호출하지 않는다는 점입니다. 그래야 무한 루프를 방지할 수 있죠.
"이미 로그인됨" 중복 방지
또 다른 예외 케이스는 이미 로그인된 상태에서 다른 탭의 로그인 페이지에 다시 접근하는 경우입니다. 이건 서버와 클라이언트 양쪽에서 막았습니다.
서버 레벨: middleware에서 /login 접근 시 accessToken 유효성을 확인하고, 유효하면 /로 리다이렉트합니다.
클라이언트 레벨: 로그인 페이지에서 subscribeAuthSync로 LOGIN_SYNC를 구독합니다. 이벤트를 받으면 isAlreadyLoggedIn = true로 설정하고, 이 상태에서 submit 시도 시 막습니다.
// login/page.tsx
const LoginPage = () => {
const [isAlreadyLoggedIn, setIsAlreadyLoggedIn] = useState(false);
const { showToast } = useToastMessage();
const router = useRouter();
// 다른 탭의 로그인 감지
useEffect(() => {
return subscribeAuthSync((message) => {
if (message.type === 'LOGIN_SYNC') {
setIsAlreadyLoggedIn(true);
router.replace('/'); // 즉시 홈으로 이동
}
if (message.type === 'LOGOUT_SYNC') {
setIsAlreadyLoggedIn(false);
}
});
}, []);
// 폼 제출 시 체크
const handleSubmit = (e) => {
if (isAlreadyLoggedIn) {
e.preventDefault(); // 제출 막기
showToast({ title: '이미 로그인되어 있습니다', type: 'info' });
router.replace('/');
return;
}
// 정상 진행
};
return (
<form action={loginAction} onSubmit={handleSubmit}>
{/* ... */}
</form>
);
};결과적으로 "다른 탭에서 이미 로그인됨"을 즉시 인지할 수 있게 되었습니다.
authSync 유틸리티의 전체 구조
멀티 탭 동기화의 핵심 로직을 담은 authSync.ts의 주요 함수들을 보면 다음과 같습니다.
// src/shared/lib/authSync.ts
// 탭 고유 ID 생성
const getTabId = () => {
let tabId = sessionStorage.getItem('auth-tab-id');
if (!tabId) {
tabId = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
sessionStorage.setItem('auth-tab-id', tabId);
}
return tabId;
};
// 로그인 이벤트 발행
export const publishLoginSync = (user: User) => {
const message = {
type: 'LOGIN_SYNC',
user: user,
ts: Date.now(),
sourceId: getTabId()
};
// BroadcastChannel로 전송
const channel = getChannel();
if (channel) {
channel.postMessage(message);
}
// localStorage에도 기록 (storage 이벤트 폴백용)
try {
localStorage.setItem('auth-sync-event', JSON.stringify(message));
} catch {}
};
// 이벤트 구독
export const subscribeAuthSync = (
onMessage: (message: AuthSyncMessage) => void
) => {
const tabId = getTabId();
const handleMessage = (message: AuthSyncMessage | null) => {
if (!message) return;
// 유효한 타입인지 확인
if (message.type !== 'LOGIN_SYNC' && message.type !== 'LOGOUT_SYNC') {
return;
}
// 자신이 보낸 메시지는 무시
if (message.sourceId === tabId) {
return;
}
onMessage(message);
};
// BroadcastChannel 리스너
const channel = getChannel();
const channelListener = (event: MessageEvent) => {
handleMessage(event.data);
};
if (channel) {
channel.addEventListener('message', channelListener);
}
// Storage 이벤트 리스너 (폴백)
const storageListener = (event: StorageEvent) => {
if (event.key !== 'auth-sync-event') return;
if (!event.newValue) return;
try {
const parsed = JSON.parse(event.newValue);
handleMessage(parsed);
} catch {}
};
window.addEventListener('storage', storageListener);
// Cleanup 함수 반환
return () => {
if (channel) {
channel.removeEventListener('message', channelListener);
channel.close();
}
window.removeEventListener('storage', storageListener);
};
};이 구조의 핵심은 BroadcastChannel을 메인으로 사용하되, storage 이벤트를 폴백으로 두어 호환성을 확보한다는 점입니다.
Zustand 스토어 구조
사용자 상태를 관리하는 Zustand 스토어는 persist 미들웨어를 사용해 localStorage에 자동으로 저장됩니다.
// src/domains/user/store/userStore.ts
export const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
// 로그인 액션
login: (userData: User) => {
set({ user: userData });
// persist 미들웨어가 자동으로 localStorage에 저장
},
// 로그아웃 액션 (서버 호출 포함)
logout: async () => {
try {
await fetch('/api/logout', { method: 'POST' });
} catch {}
localStorage.removeItem('user-storage');
set({ user: null });
publishLogoutSync();
},
// 클라이언트만 정리 (다른 탭의 로그아웃 수신 시)
clearUser: () => {
localStorage.removeItem('user-storage');
set({ user: null });
// publishLogoutSync 호출 안 함 (무한 루프 방지)
},
}),
{
name: 'user-storage',
storage: createJSONStorage(() => localStorage),
}
)
);중요한 건 이 클라이언트 상태가 단순한 "캐시"라는 점입니다. 실제 인증은 서버의 httpOnly 쿠키로만 수행되고, localStorage의 정보는 UI 표시 용도일 뿐입니다.
로그아웃 API 구현
서버의 /api/logout 엔드포인트는 모든 인증 쿠키를 삭제합니다.
// src/app/api/logout/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST() {
const cookieStore = await cookies();
// 만료 시간을 과거로 설정해 브라우저가 쿠키 삭제하도록 함
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
expires: new Date(0), // 1970-01-01
};
// 모든 인증 쿠키 삭제
cookieStore.set('accessToken', '', cookieOptions);
cookieStore.set('refresh_token', '', cookieOptions);
cookieStore.set('loginKey', '', cookieOptions);
cookieStore.set('loginType', '', cookieOptions);
cookieStore.set('loginId', '', cookieOptions);
return NextResponse.json({ message: 'Logged out' });
}쿠키를 삭제하는 방법은 만료 시간을 과거로 설정하는 것입니다. 브라우저가 알아서 쿠키를 지워주죠.
결과적으로 얻은 것
이 구조를 도입한 뒤 달라진 점들입니다.
- 탭 간 로그인/로그아웃 즉시 동기화
- 계정 꼬임 현상 제거
- 인증 책임이 서버에 명확히 귀속
- 클라이언트 상태는 언제든 재구성 가능
특히 UserHydrator를 SSR/CSR 경계에 둔 것이 큰 역할을 했습니다. 서버 기준 인증 상태를 클라이언트가 한 번 더 검증하는 구조가 되었기 때문이죠.
실제 동작을 보면 이렇습니다.
// 시나리오: 3개 탭에서 로그인 테스트
초기 상태:
탭 A, B, C 모두 로그인 페이지
탭 A에서 로그인 성공
↓
publishLoginSync 실행
↓
탭 B, C가 즉시 수신 (100ms 이내)
↓
모든 탭이 홈(/)으로 리다이렉트
↓
동일한 사용자 정보 표시마치며
"멀티 탭 인증 문제는 상태 관리 문제가 아니라, 책임 분리 문제다."